[ 롤모임 운영일지 ] - 24. "마우스가 겁나 느려져요" — 렉의 범인은 서버가 아니었다

04편에서 “동시접속 100명도 안 되는 서비스에서 렉이 걸린 이유”를 썼다. 그때 범인은 서버였다 — 요청 하나가 쿼리 130개로 불어나는 N+1이었고, 쿼리를 고치니 해결됐다.

8월 31일에 들어온 신고는 문장이 비슷한데 내용이 달랐다.

“카톡 링크로 사이트를 켜는 순간 마우스가 겁나 느려진다”

마우스가 느려진다는 건 네트워크 이야기가 아니다. API가 늦으면 화면이 늦게 뜨지, 커서가 끊기지는 않는다. 커서가 끊긴다는 건 브라우저의 메인 스레드나 합성(compositing) 단계가 막혔다는 뜻이다. 이번 편은 서버를 한 줄도 건드리지 않고 끝난 성능 사건의 기록이다.

TL;DR

  • 먼저 메모리 릭을 의심했지만 아니었다 — 전역 타이머·WS 리스너·PullToRefresh 모두 정리되고 있었다. 원인은 과도한 리렌더였다.
  • 경매 화면에서 리렌더 원인 4개를 제거했다. 가장 큰 건 handleForceAssignuseCallback이 아니라 일반 함수였던 것 — PlayerCardReact.memo가 100% 무효였고, 이 화면은 남은 시간 때문에 100ms마다 리렌더되므로 대기 선수 20명 카드가 초당 10번씩 통째로 다시 그려졌다.
  • 그런데도 “마우스가 끊긴다”는 증상은 리렌더만으로 설명되지 않았다. 진짜 범인은 CSS 한 줄이었다 — .bg-card에 걸린 backdrop-filter: blur(6px). 이 클래스는 508개소에서 쓰인다.
  • backdrop-filter는 요소마다 별도 합성 레이어를 만들고 매 프레임 뒤 배경을 샘플링한다. 스크롤도 아니고 커서만 움직여도 재합성이 돈다. 게다가 Nav는 sticky 안에 있어 최악의 조합이었다.

1. 메모리 릭부터 지웠다

“오래 켜두면 느려진다”는 말을 들으면 누수를 먼저 떠올리게 된다. 그래서 의심 가는 자리를 먼저 훑었다.

  • 전역 타이머 — 정리됨
  • WebSocket 리스너 — 정리됨
  • PullToRefresh 이벤트 — 정리됨

전부 언마운트 시점에 해제되고 있었다. 누수가 아니라면 남는 건 “매 프레임 일을 너무 많이 한다”는 쪽이다. 그쪽으로 방향을 틀었다.


2. 원인 ① — 함수 하나가 React.memo를 통째로 무효화했다

경매 화면은 남은 시간을 표시하느라 100ms마다 리렌더된다. 그래서 자식 카드들은 React.memo로 감싸 두었다. 그런데 그 memo가 한 번도 작동하지 않고 있었다.

// packages/frontend/src/pages/LiveAuctionPage.tsx (수정 전)
async function handleForceAssign(userId: number, teamId: number) {
  if (!session) return;
  // ...
}

평범한 함수 선언이다. 문제는 이게 매 렌더마다 새 참조가 된다는 것이고, 이 함수를 prop으로 받는 PlayerCardReact.memo라는 것이다. props의 얕은 비교에서 함수 참조가 매번 달라지니 memo는 항상 “바뀌었다”고 판단한다.

결과를 곱해보면 규모가 나온다. 100ms마다 리렌더 × 대기 선수 20명 카드 = 초당 200회. 카드 하나마다 툴팁·닉네임·배지가 들어 있으니 실제 렌더 노드는 그 몇 배다.

수정은 useCallback인데, 여기엔 함정이 하나 있었다.

// packages/frontend/src/pages/LiveAuctionPage.tsx (수정 후)
// ⚠️ useCallback 이어야 한다. 그냥 함수로 두면 매 렌더 새 참조가 되어 PlayerCard 의
//    React.memo 가 100% 무효가 됐다. 이 화면은 타이머로 100ms 마다 리렌더되므로,
//    대기 선수 20명 카드(각각 툴팁·배지·닉네임 포함)가 초당 10번씩 통째로 다시 그려졌다.
// ⚠️ session 은 deps 에 넣지 않고 ref 로 읽는다. deps 에 넣으면 session 이 바뀔 때마다
//    다시 memo 가 깨지고, 빼면서 클로저로 잡으면 오래된 session 으로 배정하게 된다.
const handleForceAssign = useCallback(async (userId: number, teamId: number) => {
  const session = sessionRef.current;
  if (!session) return;
  // ...
}, [sessionId, toast, releasePending]);

session을 deps에 넣으면 경매가 진행되는 내내 session이 바뀌므로 memo가 다시 깨진다. 그렇다고 deps에서 빼고 클로저로 잡으면 오래된 session으로 선수를 배정하게 된다. 그래서 값은 ref로 읽는다. deps에는 안정적인 것만 남긴다.

이건 15편의 무한 요청 루프와 정확히 같은 뿌리다. 불안정한 참조가 어디에 쓰이느냐에 따라, deps에 들어가면 무한 루프가 되고 memo 앞에 놓이면 성능 문제가 된다.


3. 원인 ②③④ — 남은 세 개

같은 화면에서 세 개를 더 걷어냈다.

② 레이더 차트가 mousemove마다 setState했다. 툴팁이 커서를 따라다니게 만든 대가로, 차트 위에서 마우스를 움직이는 내내 초당 수십 번 리렌더됐다. 축이 바뀔 때만 갱신하고, 툴팁은 커서를 쫓는 대신 그 축의 점 옆에 세우는 것으로 바꿨다.

③ 닫힌 툴팁도 매 렌더 setState를 호출했다. Tooltip 컴포넌트는 이 화면에 30개 넘게 살아 있다. 열려 있지 않으면 계산할 게 없으니 즉시 반환하게 했다.

④ 토스트 context value가 매 렌더 새 객체였다. context value가 새 객체면 소비자 전원이 같이 리렌더되고, toast를 deps에 쓴 useCallback들이 연쇄로 깨진다. useMemo로 고정했다.


4. 진짜 범인 — CSS 한 줄

여기까지 고치고도 “마우스가 끊긴다”는 증상은 설명이 부족했다. 리렌더는 자바스크립트 실행 시간을 먹지, 커서의 움직임 자체를 끊지는 않는다. 그리고 신고는 경매 화면만이 아니라 사이트를 켜는 순간이었다.

전역 CSS를 열어보니 한 줄이 있었다.

/* packages/frontend/src/globals.css (수정 전) */
.bg-card {
  backdrop-filter: blur(6px);
  -webkit-backdrop-filter: blur(6px);
}

.bg-card는 이 앱에서 508개소에 쓰인다. 카드형 UI의 기본 클래스라 글 목록, 멤버 카드처럼 반복 렌더되는 자리에 전부 붙어 있다. 한 화면에 수십 개가 깔린다는 뜻이다.

backdrop-filter가 비싼 이유는 블러 연산 자체보다 구조에 있다.

  • 요소마다 별도 합성 레이어가 생긴다.
  • 매 프레임 뒤 배경을 샘플링해서 블러를 다시 계산한다.
  • 그래서 스크롤도 아니고 커서만 움직여도 재합성이 돈다.

여기에 조건이 겹쳤다. Nav는 sticky 안에 있어서 고정 요소 + 블러라는 최악의 조합이었고, 신고자는 통합그래픽 PC를 쓰고 있었다. 그리고 사람이 많아 글·멤버가 늘수록 화면의 카드 수가 늘어 더 심해진다 — 신고가 몰린 시간대와도 맞는다.

걷어내고, 블러가 사라져도 카드가 배경과 분리돼 보이도록 알파를 올렸다.

/* packages/frontend/src/globals.css (수정 후) */
/* ⚠️ backdrop-filter: blur(6px) 를 걷어냈다.
   .bg-card 는 508개소에서 쓰이고, 목록·멤버 카드처럼 반복 렌더되는 자리라 한 화면에
   수십 개가 깔린다. backdrop-filter 는 요소마다 별도 합성 레이어를 만들고 매 프레임
   뒤 배경을 샘플링해 블러를 다시 계산한다 — 스크롤도 아니고 커서만 움직여도 재합성이
   돌아서, 저사양·통합그래픽 PC 에서 마우스 자체가 끊겼다(2026-08-31 신고).
   게다가 Nav 는 sticky 안에 있어 최악의 조합이었다.
   대신 카드 알파를 0.78 → 0.92 로 올려 블러 없이도 배경과 분리되게 했다.
   되살릴 거라면 전역이 아니라 화면당 한두 개짜리(모달 오버레이 등)에만 붙일 것. */

모달 오버레이처럼 화면당 한두 개짜리 backdrop-blur 유틸은 그대로 뒀다. 비용이 개수에 비례하는 것이지 효과 자체가 금지된 게 아니다. 전역 클래스에 붙인 게 문제였다.


5. 덤 — decoding="async"

모임 배너 이미지에 decoding="async"를 붙였다. 운영진이 올린 원본이라 용량이 클 수 있는데, 동기 디코딩이면 그 몇백 ms 동안 메인 스레드가 멈춘다. 사용자에겐 “켜자마자 버벅인다”로 체감된다. 이것도 “사이트를 켜는 순간”이라는 신고 문장과 맞는 조각이었다.


6. 지금 상태 / 배운 것

  • 리렌더 원인 4개 + 전역 backdrop-filter 제거 + 배너 비동기 디코딩까지 8월 31일 하루에 배포했다.
  • 서버 코드는 한 줄도 바뀌지 않았다. 같은 “렉이 걸린다”는 신고라도 04편과는 완전히 다른 층위의 문제였다.

배운 건 두 가지다.

증상의 동사를 따라간다. “느리다”와 “마우스가 끊긴다”는 다른 말이다. 후자는 네트워크도, 대부분의 경우 자바스크립트 실행도 아니고, 합성 단계를 의심해야 한다는 신호였다. 신고 문장을 그대로 받아적어 둔 게 도움이 됐다.

전역 클래스에 붙은 비용은 클래스 사용처 수만큼 곱해진다. backdrop-filter 한 줄은 컴포넌트 하나에 붙였다면 아무 문제가 없었다. 508개소에 붙었기 때문에 문제가 됐다. 그래서 주석에 “되살릴 거라면 전역이 아니라 화면당 한두 개짜리에만”이라고 조건까지 적어 뒀다 — 다음에 누가 예쁘게 만들고 싶어질 때 같은 함정에 빠지지 않도록.

댓글